/* 
 * Copyright (C) 2012 Mark Thompson (mark.artifice@gmail.com)
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *  
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *  
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package com.artificetech.videolibrary;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Vector;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;

import com.artificetech.videolibrary.PrioritizedThreadPoolExecutor.Prioritizable;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.opengl.Matrix;
import android.os.SystemClock;
import android.util.FloatMath;
import android.util.Log;
 
import static android.opengl.GLES20.*;

/**
 * A class displaying a carousel-type gallery.
 * 
 * @param <T> The type of item in the gallery.
 */
public class CarouselRenderer<T extends GalleryViewable> {
	private static final String TAG="GalleryRenderer";
	
	private final float[] projMatrix=new float[16];
	private final float[] viewMatrix=new float[16];
	private final float[] pvMatrix=new float[16];
	
	/** A little helper class to handle fade-in animations */
	private class TileFadeStatus {
		/** The space allocated for this tile, as a fraction of the normal space allocated (=1.0f) */
		float space;
		/** The alpha value with which to render this tile, 0..1 */
		float alpha;
		
		TileFadeStatus() {
			space=0.0f;
			alpha=0.0f;
		}
	}
	
	/** A helper enum for the visibility status of a tile. */
	private enum TileVisibility {
		/** Tiles that are, at least partially, visible now. */
		VISIBLE(true, 8, true),  
		/** Tiles that would be visible with a short (OFFSCREEN_NEARBY_TEXTURES) scroll. */
		NEAR(false, 4, false),
		/** Tiles that are further away than that. */
		NONE(false, 0, false);
		
		/** Is this tile visible right now? Force texture generation, regardless of other concerns. */
		public final boolean visible;
		/** What priority we should generate textures for at this visiblity level */
		public final int textureGenerationPriority;
		/** Whether, at this visiblity level, we should retain textures after they've been uploaded to the GPU. */
		public final boolean retainTextureBuffer;
		
		private TileVisibility(boolean _visible, int _textureGenerationPriority, boolean _retainTextureBuffer) {
			visible=_visible;
			textureGenerationPriority=_textureGenerationPriority;
			retainTextureBuffer=_retainTextureBuffer;
		}
	}
	
	/** A helper enum for the current motion status */
	private enum MotionStatus {
		/** Executing an Android 'fling' gesture. */
		FLINGING,
		/** Scrolling, with the finger continuously on the screen. */
		SCROLLING,
		/** Scrolling to a selected item, without further input */
		SNAPPING,
		/** Doing nothing. */
		STOPPED
	}
	
	/** Items currently displayed in the list; a mirror of the 'item' property of tiles most of the time; except when we've lost our GL context. */
	private Collection<T> content;
	/** The tiles that are loaded in the list */
	private List<GalleryTile<T>> tiles;
	/** Tiles that need to be added to the list (written to by other threads, copied by the renderer thread during update()) */
	private List<GalleryTile<T>> newTiles;
	/** Tiles that have non-trivial animation status */
	private Map<GalleryTile<T>, TileFadeStatus> tilesFading;
	
	/** Scroll position around the gallery */
	private float offset;
	/** Item we have selected and/or are snapping to. */
	private int selected;
	
	/** Time of last call to update(), SystemClock.uptimeMillis() */
	private long lastUpdate;
	/** How are we moving at the moment? */
	private MotionStatus motionStatus;
	/** The current 'fling' velocity. */
	private float flingVelocity;
	
	/** The current sort order applied, or null for unspecified order. */
	private Comparator<T> sortOrder;
	
	/** The sort order that's queued to be applied applied, or null for unspecified order. */
	private Comparator<T> queuedSortOrder;
	
	/** How quickly we snap when the pointer is released between two tiles */
	private float SNAP_SPEED=0.005f;
	/** How quickly we slow down after a 'fling'. */
	private float FLING_FRICTION=0.002f;
	/** How quickly a tile gets space after being loaded */
	private float FADE_IN_SPEED_SPACE=0.025f;
	/** How quickly a tile fades in after being loaded */
	private float FADE_IN_SPEED_ALPHA=0.125f;
	
	/** 
	 * The size of texture used to hold the tile image. For maximum compatability, should be a power of two.
	 * The texture is always this size and square; not all of it is used by any given tile.
	 */
	private static final int TEXTURE_SIZE=512; // TODO Make texture size resolution-dependant
	/** Whether or not to generate (and use) mipmaps for the texture. For best results, should be set when the texture size is larger than the rendered size. */
	private static final boolean TEXTURE_MIPMAPS=false;
	/** How many extra offscreen tiles should we use higher-priority texture generators for? (Integer.MAX_VALUE for all of them) */
	private static final int OFFSCREEN_NEARBY_TEXTURES=10; // 20 tiles.
	/** The priority used when regenerating textures when there's already a valid texture on the GPU. */
	private static final int REGENERATE_TEXTURE_PRIORITY=1;
	
	/** Listener for selection changed, length changed, and playback start events. */
	private List<GalleryListener<T>> listeners;
	
	/** Background thread for expensive operations. */
	private ExecutorService background;
	
	public CarouselRenderer() {
		content=new Vector<T>();
		tiles=new Vector<GalleryTile<T>>();
		newTiles=new Vector<GalleryTile<T>>();
		tilesFading=new HashMap<GalleryTile<T>, TileFadeStatus>();
		offset=0.0f;
		flingVelocity=0.0f;
		selected=-1;
		listeners=new Vector<GalleryListener<T>>();
		background=new PrioritizedThreadPoolExecutor(1, 1, Thread.MIN_PRIORITY);
		lastUpdate=SystemClock.uptimeMillis();
		motionStatus=MotionStatus.SNAPPING;
	}
	
	/** Register a listener for changes in selected item, list length or media playback. */ 
	public synchronized void addListener(GalleryListener<T> listener) {
		listeners.add(listener);
	}
	
	/** Delete a previously registered listener. */
	public synchronized void removeListener(GalleryListener<T> listener) {
		listeners.remove(listener);
	}
	
	/** Set (or clear) the sort order. */
	public synchronized void setSortOrder(Comparator<T> _sortOrder) {
		queuedSortOrder=_sortOrder;
	}
	
	/** Retrieve the current sort order (may be null) */
	public synchronized Comparator<T> getSortOrder() {
		return queuedSortOrder;
	}
	
	/** Get the selected gallery item. */
	public synchronized T getSelected() {
		if (selected==-1) return null;
		return tiles.get(selected).item;
	}
	
	/** Get the number of tiles currently displayed. */
	public synchronized int getCount() {
		if (tiles==null) return 0;
		return tiles.size();
	}
	
	/** Called when 'selected' is changed; fire all the registered selection change listeners. */
	private void updateSelection(int newSelection) {
		synchronized (this) {
			if (selected==newSelection) return;
			selected=newSelection;
		}
		T selectedItem=getSelected();
		synchronized (this) {
			for (GalleryListener<T> listener : listeners) listener.selectionChanged(this, selectedItem);
		}
	}
	
	/** Called when a user clicks on the already highlighted tile. */
	private void activate(int index) {
		updateSelection(index);
		T selectedItem=getSelected();
		synchronized (this) {
			for (GalleryListener<T> listener : listeners) listener.activateSelection(this, selectedItem);
		}
	}
	
	public void startScrolling() { flingVelocity=0.0f; motionStatus=MotionStatus.SCROLLING; }
	
	public void stopScrolling() { motionStatus=MotionStatus.SNAPPING; }
	
	/** Called when a touch input should change the position within the gallery. */
	public void deltaOffset(float delta) {
		startScrolling();
		offset+=delta;
	}
	
	/** Called when a 'fling' event occurs. */
	public void fling(float velocity) {
		motionStatus=MotionStatus.FLINGING;
		flingVelocity=velocity;
	}
	
	/** Called when a touch input 'clicks' this area. Input coordinates should be normalized (-1..1) */
	public synchronized void click(float x, float y) {
		// Go through each tile in turn
		float tileOffset=-offset;
		if (selected!=-1) { // TODO Probably a better way would be to alter the selectionIndex<->offset transformation
			GalleryTile<T> tile=tiles.get(selected);
			TileFadeStatus s=tilesFading.get(tile);
			float tileSpace=(s!=null) ? s.space : 1.0f;
			tileOffset-=tileSpace*0.5f;
		}
		
		float[] tileTransform=new float[16];
		float[] combinedTransform=new float[16];
		
		// Run through in the opposite order to rendering
		int i=0;
		ListIterator<GalleryTile<T>> it=tiles.listIterator(0);
		while (it.hasNext()) {
			GalleryTile<T> tile=it.next();
			TileFadeStatus s=tilesFading.get(tile);
			float tileSpace=(s!=null) ? s.space : 1.0f;
			
			if (getTransform(tileTransform, tileOffset+tileSpace*0.5f)) {
				Matrix.multiplyMM(combinedTransform, 0, pvMatrix, 0, tileTransform, 0);
				
				// Hit it?
				if (tile.contains(combinedTransform, x, y)) {
					flingVelocity=0.0f;
					motionStatus=MotionStatus.SNAPPING;
					// If it was already selected, activate it.
					if (i==selected) {
						activate(i);
					}
					else {
						updateSelection(i);
					}
					break;
				}
			}

			++i;
			tileOffset += tileSpace;
		}
	}

	/** Convert a scroll offset to a tile index, or position between two tile indices. */
	private float offsetToSelectionIndex(float offset) {
		// Are we before any tile?
		if (offset<0.0f) return offset;
		
		int index=0;
		ListIterator<GalleryTile<T>> it=tiles.listIterator(0);
		while (it.hasNext()) {
			GalleryTile<T> tile=it.next();
			TileFadeStatus s=tilesFading.get(tile);
			float tileSpace=(s!=null) ? s.space : 1.0f;
			// Are we in the space between this tile and the next?
			if (offset<tileSpace) {
				return index+(offset/tileSpace);
			}
			offset-=tileSpace;
			++index;
		}
		
		// We must be after any tile.
		return index+offset;
	}
	
	/** Convert a tile index back to a scroll offset. */
	private float selectionIndexToOffset(float selectionIndex) {
		// Are we before any tile?
		if (selectionIndex<0) {
			return selectionIndex;
		}
		
		float offset=0.0f;
		ListIterator<GalleryTile<T>> it=tiles.listIterator(0);
		while (it.hasNext()) {
			GalleryTile<T> tile=it.next();
			TileFadeStatus s=tilesFading.get(tile);
			float tileSpace=(s!=null) ? s.space : 1.0f;
			// Are we in the space between this tile and the next?
			if (selectionIndex<1.0f) {
				return offset+selectionIndex*tileSpace;
			}
			offset+=tileSpace;
			selectionIndex-=1.0f;
		}
		
		// We must be after any tile.
		return offset+selectionIndex;
	}
	
	/** Call this frequently. */
	public void update() {
		long now=SystemClock.uptimeMillis();
		long elapsed=now-lastUpdate;
		if (elapsed>0) update(elapsed);
		lastUpdate=now;
	}
	
	/** This does the actual work of any update. */
	private synchronized void update(long elapsed) {		
		switch (motionStatus) {
		case FLINGING:
			offset+=flingVelocity;
			if (flingVelocity>0.0f) {
				flingVelocity-=FLING_FRICTION*elapsed;
				if (flingVelocity<0.0f) {
					flingVelocity=0.0f;
					motionStatus=MotionStatus.SNAPPING;
				}
			}
			else {
				flingVelocity+=FLING_FRICTION*elapsed;
				if (flingVelocity>0.0f) {
					flingVelocity=0.0f;
					motionStatus=MotionStatus.SNAPPING;
				}
			}
			break;
		case SNAPPING:
			float targetOffset=selectionIndexToOffset((selected!=-1) ? selected : 0.0f);
			float delta=targetOffset-offset;
			if (Math.abs(delta)<0.001f) motionStatus=MotionStatus.STOPPED; 
			float ratio=1.0f-(float)Math.exp(-SNAP_SPEED*elapsed);
			offset+=delta*ratio;
			break;
		}
		
		if (tiles!=null) {
			// Any new videos to add?
			
			boolean tilesAdded=false;
			
			synchronized(newTiles) {
				if (!newTiles.isEmpty()) {
					tilesAdded=true;
					for (GalleryTile<T> tile : newTiles) addTileInner(tile);
					newTiles.clear();
					// Tell any listeners about the change of length
					for (GalleryListener<T> listener : listeners) listener.lengthChanged(this, tiles.size());
				}
			}
			
			if (tilesAdded) {
				// Do we now have a selection where previously we didn't?
				if (selected==-1) {
					updateSelection(0);
					motionStatus=MotionStatus.SNAPPING;
				}
			}
			
			// Fade in tiles
			float safeOffset=offsetToSelectionIndex(offset); // We need a measure of position that won't be affected by tiles changing space
			Vector<GalleryTile<T>> reaper=new Vector<GalleryTile<T>>();
			for (Map.Entry<GalleryTile<T>, TileFadeStatus> e : tilesFading.entrySet()) {
				GalleryTile<T> tile=e.getKey();
				TileFadeStatus s=e.getValue();
				s.space+=FADE_IN_SPEED_SPACE;
				if (s.space>1.0f) s.space=1.0f;
				if (tile.hasTexture()) {
					s.alpha+=FADE_IN_SPEED_ALPHA;
					if (s.alpha>1.0f) s.alpha=1.0f;
				}
				if (s.space>=1.0f && s.alpha>=1.0f) reaper.add(e.getKey());
			}
			for (GalleryTile<T> t : reaper) tilesFading.remove(t);
			offset=selectionIndexToOffset(safeOffset);
		}
		
		// Were we supposed to update the sort order?
		if (sortOrder!=queuedSortOrder) {
			sortOrder=queuedSortOrder;
			Collections.sort(tiles, new Comparator<GalleryTile<T>>() {
				public int compare(GalleryTile<T> lhs, GalleryTile<T> rhs) {
					return sortOrder.compare(lhs.item, rhs.item);
				}
			});
			selected=-1;			
		}
		
		if (motionStatus==MotionStatus.FLINGING || motionStatus==MotionStatus.SCROLLING) {
			int newSelection=Math.round(offsetToSelectionIndex(offset));
			
			// Clamp to visible tiles only
			if (tiles.isEmpty()) {
				newSelection=-1;
			}
			else {
				if (newSelection>tiles.size()-1) newSelection=tiles.size()-1;
				if (newSelection<0) newSelection=0;
			}
			
			updateSelection(newSelection);
		}
	}

	/** Prepares a tile for rendering, adding it to the correct place in the list. */
	private void addTileInner(GalleryTile<T> tile) {
		content.add(tile.item);
		tilesFading.put(tile, new TileFadeStatus());
		// Is there a specific sort order we should adhere to?
		if (sortOrder!=null) {
			for (int i=0; i<tiles.size(); ++i) {
				if (sortOrder.compare(tile.item, tiles.get(i).item)<0) {
					tiles.add(i, tile);
					// Did we just shift the selected item back?
					if (i<=selected) ++selected;
					return;
				}
			}
			// Otherwise, fall through and add it at the end.
		}
		tiles.add(tile);
	}
	
	
	/**
	 * This takes creates a new bitmap, consisting of the source, outlined with a coloured border,
	 * then a transparent border, all sized (preserving aspect) to fit the given maximum dimensions.
	 * 
	 * If set, boundsOut is set to the UV-space position of the modified image within the texture,
	 * as it may not fill all of it.
	 *  
	 * @param source			The bitmap to outline
	 * @param width				The width of the output bitmap.
	 * @param height			The height of the output bitmap.
	 * @param clearBorderSize	The width of the clear border added around the bitmap.
	 * @param colourBorderSize	The width of the coloured border added around the bitmap.
	 * @param colour			The colour (RGBA 8888) to outline the bitmap with
	 * @param boundsOut			UV-space coordinates of the used portion of the image will be written here.
	 */
	private static Bitmap formatBitmap(Bitmap source, int width, int height, int clearBorderSize, int colourBorderSize, int colour, RectF boundsOut) {
		int borderSize=clearBorderSize+colourBorderSize;
		int maxWidth=width-2*borderSize;
		int maxHeight=height-2*borderSize;
		
		assert(source!=null);
		assert(maxWidth>0);
		assert(maxHeight>0);
		assert(clearBorderSize>=0);
		assert(colourBorderSize>=0);
		
		Bitmap destination=Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
		assert(destination.getConfig()==Bitmap.Config.ARGB_8888);
		Canvas canvas=new Canvas(destination);
		
		// Compute the size we need to render the source bitmap at
		int srcWidth=source.getWidth();
		int srcHeight=source.getHeight();
		
		int dstWidth=srcWidth, dstHeight=srcHeight;
		
		if (dstWidth>maxWidth) { // Too wide. 
			dstHeight=(dstHeight*maxWidth)/dstWidth;
			dstWidth=maxWidth;
		}
		
		if (dstHeight>maxHeight) { // Too high.
			dstWidth=(dstWidth*maxHeight)/dstHeight;
			dstHeight=maxHeight;
		}
		
		// The area occupied by the image.
		Rect imageDst=new Rect(borderSize, borderSize, borderSize+dstWidth, borderSize+dstHeight);
		// The area occupied by the colour border.
		Rect borderDst=new Rect(imageDst.left-colourBorderSize, imageDst.top-colourBorderSize, imageDst.right+colourBorderSize, imageDst.bottom+colourBorderSize);
		
		// Set the bitmap to be fully transparent.
		Paint alphaPaint=new Paint();
		alphaPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
		canvas.drawRect(0, 0, width, height, alphaPaint);
		
		// Add the coloured border.
		Paint borderPaint=new Paint();
		borderPaint.setColor(colour);
		canvas.drawRect(borderDst, borderPaint);
		
		// Add the bitmap.
		canvas.drawBitmap(source, new Rect(0, 0, srcWidth, srcHeight), imageDst, null);
		
		if (boundsOut!=null) {
			// Convert the boundaries to UV space
			boundsOut.left = 0.0f;
			boundsOut.right = (float)(dstWidth+2*borderSize)/width;
			boundsOut.top = 0.0f;
			boundsOut.bottom = (float)(dstHeight+2*borderSize)/height;
		}
		
		return destination;
	}
	
	public void surfaceCreated() {
		// Create video tiles.
		GalleryTile.init(background);
		
		// Recreate the textures for all the tiles.
		for (GalleryTile<T> tile : tiles) tile.contextLost();
	}
	
	/** 
	 * Start the process of adding a new tile.
	 * This does some work on a background thread, so the tile won't have a texture immediately.
	 * It's safe to call this from any thread.
	 */
	public void add(final T c) {
		// Create the tile immediately.
		final GalleryTile<T> t=new GalleryTile<T>(c);
		synchronized(newTiles) {
			newTiles.add(t);
		}
	}
	
	/** 
	 * Sets the rendering paramters of this object.
	 * 
	 * Projections will be set so that the active object appears at roughly centre_x, centre_y (normalized viewport coordinates)
	 * appearing correct for the given aspect. View will approximately fill the height of the viewport at scale=1.0f.
	 */
	public void setView(float centre_x, float centre_y, float aspect, float scale) {
		float v=0.2f/scale;
		float w=v*aspect;
		Matrix.frustumM(projMatrix, 0, -centre_x*v-w, -centre_x*v+w, centre_y*v-v, centre_y*v+v, 1.0f, 100.0f);
	}
	
	public void render() {
		// Lazily update
		update();
		
		checkGLError("pre render()");
		
		// Clear screen
		glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
		
		// Set camera position
		Matrix.setLookAtM(viewMatrix, 0, 30.0f, 2.0f, 3.0f, 30.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f);
		
		// Calculate the combined transform
		Matrix.multiplyMM(pvMatrix, 0, projMatrix, 0, viewMatrix, 0);
				
		// Draw the video tiles
		float tileOffset=-offset;
		if (selected!=-1) { // TODO Probably a better way would be to alter the selectionIndex<->offset transformation
			GalleryTile<T> tile=tiles.get(selected);
			TileFadeStatus s=tilesFading.get(tile);
			float tileSpace=(s!=null) ? s.space : 1.0f;
			tileOffset-=tileSpace*0.5f;
		}
		
		// We need to run across the list first to work out the correct starting offset
		ListIterator<GalleryTile<T>> it=tiles.listIterator(0);
		while (it.hasNext()) {
			GalleryTile<T> tile=it.next();
			TileFadeStatus s=tilesFading.get(tile);
			float tileSpace=(s!=null) ? s.space : 1.0f;
			tileOffset+=tileSpace;
		}
		
		float[] tileTransform=new float[16];
		float[] combinedTransform=new float[16];
		
		it=tiles.listIterator(tiles.size());
		while (it.hasPrevious()) {
			GalleryTile<T> tile=it.previous();
			TileFadeStatus s=tilesFading.get(tile);
			float tileSpace=(s!=null) ? s.space : 1.0f;
			tileOffset -= tileSpace;
			
			// Do we want textures for this tile?
			TileVisibility visibility=getTileVisibility(tileOffset+tileSpace*0.5f);
			int texturePriority=visibility.textureGenerationPriority;
			
			// If we already have a texture, the maximum priority is reduced.
			if (tile.hasTexture() && texturePriority>REGENERATE_TEXTURE_PRIORITY) texturePriority=REGENERATE_TEXTURE_PRIORITY;
			
			// Do we want to retain this texture in memory?
			tile.setRetainTextureBuffer(visibility.retainTextureBuffer);
					
			tile.updateTexture();
						
			if (texturePriority>0) {
				// If we're currently scrolling, don't preload other textures.
				if (visibility.visible || motionStatus==MotionStatus.STOPPED) {
					// Generate this texture in the background.
					tile.generateTextureAsync(texturePriority);
				}
			}
			else {
				if (tile.hasTexture() || tile.isLoadingTexture()) {
					tile.releaseTexture();
				}
			}
			
			if (getTransform(tileTransform, tileOffset+tileSpace*0.5f)) {
				Matrix.multiplyMM(combinedTransform, 0, pvMatrix, 0, tileTransform, 0);
				
				// If we don't have a texture, reset the alpha value.
				if (!tile.hasTexture()) {
					if (s==null) {
						s=new TileFadeStatus();
						s.space=1.0f;
						tilesFading.put(tile, s);
					}
					s.alpha=0.0f;
				}
				
				float alpha=(s!=null) ? s.alpha : 1.0f;
				
				tile.render(combinedTransform, alpha);
			}
		}
	}
	
	/** Helper method - return the angle around the centre of the carousel a given tile should be placed at. */
	private static float helperGetAngle(float offset) {
		// The difference in angle (radians) between successive tiles, near offset 0.
		final float deltaAngle=0.1f;
		// The angle around the centre of the carousel the tile should be rendered at.
		return deltaAngle*(0.5f*offset-0.5f*(float)Math.exp(-1-offset));
	}
	
	/** Work out how far the given tile is from being displayed. */
	private static TileVisibility getTileVisibility(float offset) {
		// If it's actually on screen, it has the highest priority.
		float angle=helperGetAngle(offset-1);
		if (angle>-0.3f && angle<0.4f) return TileVisibility.VISIBLE;
		// If we've asked that all remaining textures are loaded, do so.
		if (OFFSCREEN_NEARBY_TEXTURES==Integer.MAX_VALUE) return TileVisibility.NEAR; 
		// Test the tiles within +/- 10 of here, see if they're onscreen.
		float angle1=helperGetAngle(offset+OFFSCREEN_NEARBY_TEXTURES);
		float angle2=helperGetAngle(offset-OFFSCREEN_NEARBY_TEXTURES);
		if (angle1>-0.3f && angle2<0.4f) return TileVisibility.NEAR;
		return TileVisibility.NONE;
	}
	
	/** 
	 * Compute whether a tile should be rendered, and if so, the transform to use when doing so. 
	 * 
	 * The selected tile will use offset 0. Tiles closer to the viewer will have negative offsets.
	 * 
	 * If the tile should be rendered, writes the transform into the 'out' array and returns true. 
	 * Otherwise, returns false.
	 */
	private static boolean getTransform(float[] out, float offset) {
		// The angle the tile should be rotated about its centre.
		float rAngle=helperGetAngle(offset);
		// The angle around the centre of the carousel the tile should be rendered at.
		float pAngle=helperGetAngle(offset-1);
				
		// Don't render tiles that are offscreen.
		if (pAngle<-0.3f || pAngle>0.4f) return false;
		
		// Compute the rotation matrix, based on rAngle.
		float[] rotate=new float[16];
		Matrix.setIdentityM(rotate, 0);
		Matrix.rotateM(rotate, 0, (float)Math.toDegrees(rAngle), 0.0f, 1.0f, 0.0f); // Why can't they use radians, like sensible people?
		
		// Compute the position the tile should be rendered at.
		float x=30.0f * FloatMath.cos(pAngle);
		float z=-4.0f * FloatMath.sin(pAngle);
		
		// Convert the position into a translation matrix.
		float[] translate=new float[16];
		Matrix.setIdentityM(translate, 0);
		Matrix.translateM(translate, 0, x, 0.0f, z);
		
		// Concatenate the two transforms, and write to the output.
		Matrix.multiplyMM(out, 0, translate, 0, rotate, 0);
		return true;
	}
	
	// Some helper functions.
	/** Helper - Compile a shader from a string. */
	private static int loadShader(int type, String code) {
		int shader=glCreateShader(type);
		glShaderSource(shader, code);
		glCompileShader(shader);
		
		// Log any output GL has from this compilation.
		Log.i(TAG, glGetShaderInfoLog(shader));
		return shader;
	}
	
	/** Helper - Check for GL error status, and write any problems to the log. */
	public static void checkGLError(String where) {
		int error;
		while ((error=glGetError())!=GL_NO_ERROR) {
			Log.e(TAG, where + ": GL Error: " + error);
		}
	}
	
	/** Helper - generate an NIO FloatBuffer, suitable for GL, from a standard Java array. */
	private static FloatBuffer getFloatBuffer(float[] src) {
		ByteBuffer bb=ByteBuffer.allocateDirect(src.length * 4);
		bb.order(ByteOrder.nativeOrder());
		FloatBuffer rv=bb.asFloatBuffer();
		rv.put(src);
		rv.position(0);
		return rv;
	}
	
	/** Helper - generate a single GL texture name. */
	private static int genTexture() {
		int[] textures=new int[1];
		glGenTextures(1, textures, 0);
		return textures[0];
	}
	
	/** Helper - delete a single GL texture. */
	private static void deleteTexture(int id) {
		glDeleteTextures(1, new int[] { id }, 0);
	}
	
	/** A single tile as viewed in the carousel. */
	private static class GalleryTile<T extends GalleryViewable> {
		private static final String vertexShaderCode=
			"uniform mat4 uTransform;" +
			"uniform float uAlpha;" +
			"attribute vec4 vPosition;" +
			"attribute vec2 vUV; " +
			"attribute float vAlpha; " +
			"varying vec2 fUV;" +
			"varying float fAlpha;" +
					
			"void main() {" +
			"	gl_Position = uTransform * vPosition;" +
			"	fUV = vUV;" +
			"	fAlpha = vAlpha * uAlpha;" +
			"}";
		
		private static final String fragmentShaderCode=
			"precision mediump float;" +
			"uniform sampler2D uTexture;" +
			"varying vec2 fUV;" +
			"varying float fAlpha;" +
			"void main() {" +
			"	vec4 c=texture2D(uTexture, fUV);" +
			"	c.a *= fAlpha;" +
			"	gl_FragColor = c;" +
			"}";
		
		private static enum TextureStatus {
			/** This texture isn't loaded */
			UNLOADED,
			/** This texture is queued for creation. */
			QUEUED,
			/** This texture is being created (probably on a background thread) */
			CREATING,
			/** This texture is ready for upload, texture buffer contains valid data */
			CREATED,
			/** This texture is uploaded to the GPU, the texture handle is valid */
			UPLOADED
		};
		
		/** The size of the 'reflection', in UV space (0..1) */
		private static final float REFLECTION_SIZE=0.25f; // 25% of a full height image, proportionally more of anything smaller.
		
		/** The opacity of the 'reflection' (0..1) */
		private static final float REFLECTION_ALPHA=0.5f; // 50%
		
		/** Set by init() */
		private static int vertexShader, fragmentShader, shaderProgram;
		/** 
		 * This is used to transfer texture data from the worker thread where it's generated to the render thread.
		 * 
		 * We optionally keep it around to recover textures faster after a GL context switch
		 */
		private ByteBuffer textureBuffer;
		/** If set, we try (no guarantees) to keep textureBuffer around for future use after the GL context is lost. */
		private boolean retainTextureBuffer;
		/** Set when the texture is ready for upload. */
		private TextureStatus textureStatus;
		/** What level of priority do we place on getting this texture generated? */
		private int textureGeneratorPriority;
		/** The queued texture generation task. */
		private Future<?> textureGeneratorTask;
		/** Set by uploadTexture() */
		private int texture;
		/** NIO buffer for vertex positions. Set by the constructor */
		private FloatBuffer bvPositions;
		/** NIO buffer for vertex UVs. Set by the constructor */
		private FloatBuffer bvUVs;
		/** NIO buffer for vertex alphas. Set by the constructor */
		private FloatBuffer bvAlphas;
		
		/** The dimensions of this tile, in UV space (0...1) */
		private RectF dimensions;

		/** The gallery object we're referring to. */
		final T item;
		
		/** The background thread (probably) that we use to do asynchronous texture loads. */
		private static ExecutorService background;
		
		/** This must be called when the GL surface is acquired (and before any rendering) */
		public static void init(ExecutorService _background) {
			checkGLError("pre init");
			
			background=_background;
			
//			glDeleteShader(vertexShader);
//			glDeleteShader(fragmentShader);
//			glDeleteProgram(shaderProgram);
//			
//			checkGLError("glDelete");
			
			// Compile and link shaders.
			vertexShader=loadShader(GL_VERTEX_SHADER, vertexShaderCode);
			fragmentShader=loadShader(GL_FRAGMENT_SHADER, fragmentShaderCode);
			
			shaderProgram=glCreateProgram();
			glAttachShader(shaderProgram, vertexShader);
			glAttachShader(shaderProgram, fragmentShader);
			glLinkProgram(shaderProgram);
			
			checkGLError("compile shaders");
		}
		
		/** Create a new gallery tile, queuni */
		public GalleryTile(T _item) {
			item=_item;
			textureStatus=TextureStatus.UNLOADED;
			
			// Resize this tile if necessary
			generateGeometry(new RectF(0.0f, 0.0f, 1.0f, 1.0f));
			
			// The alpha values are independant of tile geometry
			float[] vAlphas = {
				1.0f,
				1.0f,
				1.0f,
				1.0f,
				REFLECTION_ALPHA,
				REFLECTION_ALPHA,
				0.0f,				// Fade the reflection to 0.
				0.0f
			};
			
			bvAlphas=getFloatBuffer(vAlphas);
		}

		private void generateGeometry(RectF textureBounds) {
			// Generate vertex coordinates
			float width=textureBounds.width(), height=textureBounds.height();
			
			// We want to be centred in X, but aligned so the bottom of the image is always at -0.5f.
			float x0=-0.5f*width;
			float x1=0.5f*width;
			float y0=height-0.5f;
			float y1=-0.5f;
						
			dimensions=new RectF(x0, y1, x1, y0);
			
			float[] vPositions = new float[] {
				x0, y0, 0.0f,
				x1, y0, 0.0f,
				x0, y1, 0.0f,
				x1, y1, 0.0f,
				x0, y1, 0.0f,		// Degenerate polygon, but these vertices have different alpha
				x1, y1, 0.0f,
				x0, y1-REFLECTION_SIZE, 0.0f,	// 'Reflection'
				x1, y1-REFLECTION_SIZE, 0.0f
			};
			
			bvPositions=getFloatBuffer(vPositions);
			
			float[] vUVs=new float[] {
				textureBounds.left, textureBounds.top,
				textureBounds.right, textureBounds.top,
				textureBounds.left, textureBounds.bottom,
				textureBounds.right, textureBounds.bottom,
				textureBounds.left, textureBounds.bottom,
				textureBounds.right, textureBounds.bottom,
				textureBounds.left, textureBounds.bottom-REFLECTION_SIZE,
				textureBounds.right, textureBounds.bottom-REFLECTION_SIZE
			};
			
			bvUVs=getFloatBuffer(vUVs);
		}
		
		public boolean hasTexture() {
			return texture!=0;
		}
		
		public boolean isLoadingTexture() {
			synchronized (textureStatus) {
				return textureStatus!=TextureStatus.UNLOADED && textureStatus!=TextureStatus.UPLOADED;
			}
		}
		
		/** Generate a texture for the tile. This is designed to be called from a worker thread. */
		private void generateTextureInner() {
			long startTime=SystemClock.uptimeMillis();
			long retrieveDuration;
			
			synchronized (textureStatus) {
				textureStatus=TextureStatus.CREATING;
			}
			
			try {
				// Retrieve the image
				Bitmap coverArt=item.getCoverArt();
				long now=SystemClock.uptimeMillis();
				retrieveDuration=now-startTime;
				startTime=now;
				
				// Resize the image and add a border
				RectF textureBounds=new RectF();
				Bitmap textureSource=formatBitmap(coverArt, TEXTURE_SIZE, TEXTURE_SIZE, 4, 4, 0xFF000000, textureBounds); 
				
				synchronized (textureStatus) {
					// Has something happened to abort generation?
					if (textureStatus==TextureStatus.UNLOADED) {
						textureBuffer=null;
						return;
					}
				}
				
				// Prepare image for upload
				textureBuffer=ByteBuffer.allocate(TEXTURE_SIZE*TEXTURE_SIZE*4);
				textureBuffer.order(ByteOrder.nativeOrder());
				textureSource.copyPixelsToBuffer(textureBuffer);
				textureBuffer.position(0);
				
				generateGeometry(textureBounds);
			}
			catch (OutOfMemoryError oome) {
				// Well, that can happen.
				// If we abort, perhaps the next time we try, it'll work?
				synchronized (textureStatus) {
					textureStatus=TextureStatus.UNLOADED;
					textureBuffer=null;
				}
				
				return;
			}
			
			long generateDuration=SystemClock.uptimeMillis()-startTime;
			
			Log.i(TAG, "Cover art retrieval took: "+retrieveDuration+" ms ("+item.toString()+" priority "+textureGeneratorPriority+")");
			Log.i(TAG, "Texture generation took: "+generateDuration+" ms ("+item.toString()+" priority "+textureGeneratorPriority+")");
			
			synchronized (textureStatus) {
				// Has something else happened while we were creating the image?
				switch (textureStatus) {
				case CREATING:
					// No, ready for upload.
					textureStatus=TextureStatus.CREATED;
					break;
				case QUEUED:
				case CREATED:
				case UPLOADED:
					// That shouldn't happen; only one of these should be active at a time.
					throw new RuntimeException("Multiple textures created");
				case UNLOADED:
					// Something else has thrown the texture away.
					textureBuffer=null;
				}
			}
		}
		
		private class TextureGeneratorTask implements Runnable, Prioritizable {
			public int getPriority() { return textureGeneratorPriority; }
			public void run() { generateTextureInner(); }
			public String toString() { return item.toString()+": "+textureGeneratorPriority; }
		}
		
		/** Queue up texture generation for this tile, on the given ExecutorService (probably a background thread). */
		public void generateTextureAsync(int priority) {
			synchronized (textureStatus) {
				// If the priority has changed, and we've not yet started generating the texture, cancel it.
				if (priority!=textureGeneratorPriority) {
					if (textureStatus==TextureStatus.QUEUED) {
						textureGeneratorTask.cancel(true);
						if (!textureGeneratorTask.isDone()) {
							Log.i(TAG, "Could not cancel texture generation task: "+item.toString()+": "+priority);
							return;
						}
						
						textureGeneratorTask=null;
						textureStatus=TextureStatus.UNLOADED;
					}
					textureGeneratorPriority=priority;
				}
				
				if (textureStatus!=TextureStatus.UNLOADED) return;
				textureStatus=TextureStatus.QUEUED;
				
				if (textureGeneratorTask!=null) throw new RuntimeException();
				textureGeneratorTask=background.submit(new TextureGeneratorTask());
			}
		}
		
		/** 
		 * Ask this to keep (or not to keep) texture data in main memory after upload to the GPU.
		 * 
		 * Keeping it allows faster recovery after a GL context switch, but doing it for too many things will run us out of memory 
		 */
		public void setRetainTextureBuffer(boolean _retainTextureBuffer) {
			retainTextureBuffer=_retainTextureBuffer;
			// Do we need to ditch something we're already keeping?
			if (!retainTextureBuffer) {
				synchronized (textureStatus) {
					if (textureStatus==TextureStatus.UPLOADED) textureBuffer=null;
				}
			}
			// Otherwise, do we need to reload something into memory that was already uploaded and forgotten about?
			else {
				synchronized (textureStatus) {
					if (textureBuffer==null && textureStatus==TextureStatus.UPLOADED) {
						textureStatus=TextureStatus.UNLOADED;
					}
				}
			}
		}
		
		/** 
		 * Check whether the texture for this tile has been created, and upload it if so. 
		 * Call this frequently (so we don't have NIO buffers using all our memory),
		 * and from the main renderer thread.
		 **/
		public void updateTexture() {
			synchronized (textureStatus) {
				// We have data ready to upload?
				if (textureStatus==TextureStatus.CREATED) uploadTexture();
			}
		}
		
		/** This has to be called from the main thread, or GL gets unhappy. */
		public void uploadTexture() {
			synchronized (textureStatus) {
				if (textureStatus==TextureStatus.UPLOADED) return;
				if (textureStatus!=TextureStatus.CREATED) throw new RuntimeException("Texture not ready for upload");
				long startTime=SystemClock.uptimeMillis();
				textureGeneratorTask=null;
				texture=genTexture();
				glBindTexture(GL_TEXTURE_2D, texture);
				glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
				glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
				glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, TEXTURE_MIPMAPS ? GL_LINEAR_MIPMAP_LINEAR : GL_LINEAR);
				glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
				glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, TEXTURE_SIZE, TEXTURE_SIZE, 0, GL_RGBA, GL_UNSIGNED_BYTE, textureBuffer);
				if (TEXTURE_MIPMAPS) glGenerateMipmap(GL_TEXTURE_2D);
				// We don't need this any more.
				if (!retainTextureBuffer) textureBuffer=null;
				textureStatus=TextureStatus.UPLOADED;
				long duration=SystemClock.uptimeMillis()-startTime;
				Log.i(TAG, "Texture upload took: "+duration+" ms");
			}
		}
		
		/** This should be called when the tile is no longer displayable. */
		public void releaseTexture() {
			synchronized (textureStatus) {
				if (textureGeneratorTask!=null && !textureGeneratorTask.isDone()) textureGeneratorTask.cancel(true);
				textureGeneratorTask=null;
				textureStatus=TextureStatus.UNLOADED;
				deleteTexture(texture);
				texture=0;
			}
		}
		
		/** This should be called when the GL context is lost. */
		public void contextLost() {
			texture=0;
			if (textureStatus==TextureStatus.UPLOADED) {
				// Do we have a copy lying around?
				if (textureBuffer!=null) {
					textureStatus=TextureStatus.CREATED;
				}
				else {
					textureStatus=TextureStatus.UNLOADED;
				}
			}
		}
		
		/** Catch textures not being properly freed. */
		public void finalize() {
			if (texture!=0) {
				Log.e(TAG, "Texture freed by finalize()");
				releaseTexture();
			}
		}
		
		/**
		 * Render the tile.
		 * 
		 * @param pvMatrix	The combined model-view-projection transform matrix we should use to render this tile.
		 * @param alpha		The alpha value with which to modulate this tile (used to fade in)
		 */
		public void render(float[] pvMatrix, float alpha) {
			checkGLError("draw start");
			if (!hasTexture()) return;
			
			// Bind the shader program
			glUseProgram(shaderProgram);
			checkGLError("glUseProgram");
			
			int uniform_transform=glGetUniformLocation(shaderProgram, "uTransform");
			int uniform_texture=glGetUniformLocation(shaderProgram, "uTexture");
			int uniform_alpha=glGetUniformLocation(shaderProgram, "uAlpha");
			int attrib_vPosition=glGetAttribLocation(shaderProgram, "vPosition");
			int attrib_vUV=glGetAttribLocation(shaderProgram, "vUV");
			int attrib_vAlpha=glGetAttribLocation(shaderProgram, "vAlpha");
			
			checkGLError("glGetAttribLocation");
			
			// Enable relevant vertex arrays
			glEnableVertexAttribArray(attrib_vPosition);
			glEnableVertexAttribArray(attrib_vUV);
			glEnableVertexAttribArray(attrib_vAlpha);
			
			checkGLError("glEnableVertexAttribArray");
			
			// Upload the vertex data.
			glVertexAttribPointer(attrib_vPosition, 3, GL_FLOAT, false, 0, bvPositions);
			glVertexAttribPointer(attrib_vUV, 2, GL_FLOAT, false, 0, bvUVs);
			glVertexAttribPointer(attrib_vAlpha, 1, GL_FLOAT, false, 0, bvAlphas);
			
			checkGLError("glVertexAttribPointer");
			
			// Bind the texture
			glBindTexture(GL_TEXTURE_2D, texture);
			glUniform1i(uniform_texture, 0);
			
			checkGLError("glBindTexture");
			
			// Upload the transform matrix, alpha
			glUniformMatrix4fv(uniform_transform, 1, false, pvMatrix, 0);
			glUniform1f(uniform_alpha, alpha);
			
			checkGLError("glUniform");
			
			// Render!
			glDrawArrays(GL_TRIANGLE_STRIP, 0, 8);
			
			checkGLError("glDrawArrays");
			
			// Disable vertex arrays
			glDisableVertexAttribArray(attrib_vAlpha);
			glDisableVertexAttribArray(attrib_vUV);
			glDisableVertexAttribArray(attrib_vPosition);
		}
		
		/** Returns true, if the given coordinates are contained by the projection of this tile. */
		boolean contains(float[] pvMatrix, float x, float y) {
			float[] invMatrix=new float[16];
			// Does this matrix have an inverse?
			if (!Matrix.invertM(invMatrix, 0, pvMatrix, 0)) return false;
			// Transform our ray into object space.
			float[] near={x, y, 0.0f, 1.0f};
			float[] far={x, y, 1.0f, 1.0f};
			float[] tNear=new float[4];
			float[] tFar=new float[4];
			Matrix.multiplyMV(tNear, 0, invMatrix, 0, near, 0);
			Matrix.multiplyMV(tFar, 0, invMatrix, 0, far, 0);
			
			float x0=tNear[0]/tNear[3];
			float y0=tNear[1]/tNear[3];
			float z0=tNear[2]/tNear[3];
			float x1=tFar[0]/tFar[3];
			float y1=tFar[1]/tFar[3];
			float z1=tFar[2]/tFar[3];
			
			float dx=x1-x0, dy=y1-y0, dz=z1-z0;
			
			// Compute the intersection with the z=0 plane.
			float lambda=-z0/dz;
			float ix=x0+lambda*dx;
			float iy=y0+lambda*dy;
			
			// Does it intersect this tile?
			return dimensions.contains(ix, iy);
		}
	}
}

/** Neat effect
 * if (angle>-2.0f && angle<2.0f) {
	float[] rotate=new float[16];
	Matrix.setIdentityM(rotate, 0);
	Matrix.rotateM(rotate, 0, (float)Math.toDegrees(angle), 0.0f, 1.0f, 0.0f);
	
	float x=10.0f * (float)Math.cos(angle);
	float z=10.0f * (float)Math.sin(angle);
	x=Math.signum(x) * (x*x)/10.0f;
	
	float[] translate=new float[16];
	Matrix.setIdentityM(translate, 0);
	Matrix.translateM(translate, 0, x, 0.0f, z);
	
	float[] rotateTranslate=new float[16];
	Matrix.multiplyMM(rotateTranslate, 0, rotate, 0, translate, 0);
	
	float[] transform=new float[16];
	Matrix.multiplyMM(transform, 0, pvMatrix, 0, rotateTranslate, 0);
	
	tile.render(transform);
}
*/
